结构模式-类型对象模式
意图
创造一个类A来允许灵活地创造新 “类型”,类A的每个实例都代表了不同的对象类型。
想象我们在制作一个奇幻RPG游戏。 我们的任务是为一群想要杀死英雄的恶毒怪物编写代码。 怪物有多个的属性:生命值,攻击力,图形效果,声音表现,等等。 但是为了说明介绍的目的我们先只考虑前面两个。
游戏中的每个怪物都有当前血值。 开始时是满的,每次怪物受伤,它就下降。 怪物也有一个攻击字符串。 当怪物攻击我们的英雄,那个文本就会以某种方式展示给用户。 (我们不在乎这里怎样实现。)
设计者告诉我们怪物有不同品种,像“龙”或者“巨魔”。 每个品种都描述了一种存在于游戏中的怪物,同时可能有多个同种怪物在地牢里游荡。
品种决定了怪物的初始健康——龙开始的血量比巨魔多,它们更难被杀死。 这也决定了攻击字符——同种的所有怪物都以相同的方式进行攻击。
传统面向对象的方案
想着这样的设计方案,我们启动了文本编辑器开始编程。 根据设计,龙是一种怪物,巨魔是另一种,其他品种的也一样。 用面向对象的方式思考,这引导我们创建 Monster 基类。
class Monster
{
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;
protected:
Monster(int startingHealth)
: health_(startingHealth)
{}
private:
int health_; // 当前血值
};
在怪物攻击英雄时,公开的 getAttack() 函数让战斗代码能获得需要显示的文字。 每个子类都需要重载它来提供不同的消息。
构造器是 protected 的,需要传入怪物的初始血量。 每个品种的子类的公共构造器调用这个构造器,传入对于该品种适合的起始血量。
现在让我们看看两个品种子类:
// 龙
class Dragon : public Monster
{
public:
Dragon() : Monster(230) {}
virtual const char* getAttack()
{
return "The dragon breathes fire!";
}
};
// 巨魔
class Troll : public Monster
{
public:
Troll() : Monster(48) {}
virtual const char* getAttack()
{
return "The troll clubs you!";
}
};
每个从 Monster 派生出来的类都传入起始血量,重载 getAttack() 返回那个品种的攻击字符串。 所有事情都一如所料地运行,不久以后,我们的英雄就可以跑来跑去杀死各种野兽了。 我们继续编程,在意识到之前,我们就有了从酸泥怪到僵尸羊的众多怪物子类。
然后,很奇怪,事情陷入了困境。 设计者最终想要几百个品种,但是我们发现所有的时间都花费在写这些只有七行长的子类和重新编译上。 这会继续变糟——设计者想要协调已经编码的品种。我们之前富有产出的工作日退化成了:
- 收到设计者将巨魔的血量从48改到52的邮件。
- 签出并修改
Troll.h - 重新编译游戏。
- 签入修改。
- 回复邮件。
- 重复。
我们度过了失意的一天,因为我们变成了填数据的猴子。 设计者也感到挫败,因为修改一个数据就要老久。 我们需要的是一种无需每次重新编译游戏就能修改品种的状态。 如果设计者创建和修改品种时无需任何程序员的介入那就更好了。
为类型建类
从较高的层次看来,我们试图解决的问题非常简单。 游戏中有很多不同的怪物,我们想要在它们之间分享属性。 一大群怪物在攻击英雄,我们想要它们中的一些使用相同的攻击文本。 我们声明这些怪物是相同的“品种”,而品种决定了攻击字符串。
这种情况下我们很容易想到类,那就试试吧。 龙是怪物,每条龙都是 “龙类” 的实例。 定义每个品种为抽象基类 Monster 的子类,让游戏中每个怪物都是子类的实例反映了那点。最终的类层次是这样的:

每个怪物的实例属于某个继承怪物类的类型。 我们有的品种越多,类层次越高。 这当然是问题:添加新品种就需要添加新代码,而每个品种都需要被编译为它自己的类型。
这可行,但不是唯一的选项。 我们也可以重构代码让每个怪物有品种。 不是让每个品种继承 Monster,我们现在有单一的 Monster 类和 Breed 类。

这就成了,就两个类。注意这里完全没有继承。 通过这个系统,游戏中的每个怪物都是 Monster 的实例。 Breed 类包含了在不同品种怪物间分享的信息:开始血量和攻击字符串。
为了将怪物与品种相关联,我们给了每个 Monster 实例对包含品种信息的 Breed 对象的引用。 为了获得攻击字符串,一个怪兽可以调用它品种的方法。 Breed 类本质上定义了一个怪物的类型,这就是为啥这个模式叫做类型对象。
这个模式特别有用的一点是,我们现在可以定义全新的类型而无需搅乱代码库。 我们本质上 将部分的类型系统从硬编码的继承结构中拉出,放到可以在运行时定义的数据中去。
我们可以通过用不同值实例化 Monster 来创建成百上千的新品种。 如果从配置文件读取不同的数据初始化品种,我们就有能力完全靠数据定义新怪物品种。 这么容易,设计者也可以做到 !
这种模式的缺点
这个模型是关于将 “类型” 的定义从命令式僵硬的语言世界移到灵活但是缺少行为的对象内存世界。 灵活性很好,但是将类型提到数据丧失了一些东西。
需要手动追踪类型对象
使用像 C++ 类型系统这种东西的好处之一就是编译器自动记录类的注册。 定义类的数据自动编译到可执行的静态内存段然后就运作起来了。
使用类型对象模式,我们现在不但要负责管理内存中的怪物,同时要管理它们的类型 ——我们要保证,只要我的怪物需要,所有的品种对象都能实例化并保存在内存中。 无论何时创建新的怪物,由我们来保证能初始化为含有品种的引用。
我们从编译器的限制中解放了自己,但是代价是需要重新实现一些它以前为我们做的事情。
更难为每种类型定义行为
使用子类派生,你可以重载方法,然后做你想做的事——用程序计算值,调用其他代码,等等。 天高任鸟飞。如果我们想的话,可以定义一个怪物子类,根据月亮的阶段改变它的攻击字符串。(就像狼人)
当我们使用类型对象模式时 ,我们将重载的方法替换成了成员变量。 不再让怪物的子类重载方法,用不同的代码来计算攻击字符串,而是让我们的品种对象在不同的变量中存储攻击字符串。
这让使用类型对象定义类型相关的数据变得容易,但是定义类型相关的行为变得困难。 如果,举个例子,不同品种的怪物需要使用不同的 AI 算法,使用这个模式就面临着挑战。
有很多方式可以让我们跨越这个限制。 一个简单的方式是使用预先定义的固定行为, 然后类型对象中的数据简单地选择它们中的一个。
举例,假设我们的怪物 AI 总是处于 “站着不动”、“追逐英雄” 或者 “恐惧地呜咽颤抖”(他们不可能都是强势的龙)状态。 我们可以定义函数来实现每种行为。 然后,我们在方法中存储合适函数的引用,将 AI 算法与品种相关联。
另一个更加彻底的解决方案是真正地在数据中支持定义行为。 解释器模式和字节码模式让我们定义有行为的对象。 如果我们读取数据文件并用上面两种模式之一构建数据结构,我们就将行为完全从代码中移出,放入了数据之中。
示例代码
在第一遍实现中,让我们从简单的开始,只构建动机那节提到的基础系统。 我们从 Breed 类开始:
class Breed
{
public:
Breed(int health, const char* attack)
: health_(health),
attack_(attack)
{}
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
private:
int health_; // 初始血值
const char* attack_;
};
很简单。它基本上只是两个数据字段的容器:起始血量和攻击字符串。 让我们看看怪物怎么使用它:
class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}
const char* getAttack()
{
return breed_.getAttack();
}
private:
int health_; // 当前血值
Breed& breed_;
};
当我们建构怪物时,我们给它一个品种对象的引用。 它定义了怪物的品种,取代了之前的子类。 在构造函数中,Monster 使用的品种决定了起始血量。 为了获得攻击字符串,怪物简单地将调用转发给它的品种。
这段非常简单的代码是这章的核心思路。剩下的任何东西都是红利。